prosemirror 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +18 -0
- data/ROADMAP.md +5 -3
- data/TYPES.md +43 -0
- data/lib/prosemirror/checker.rb +2 -2
- data/lib/prosemirror/coerce.rb +7 -0
- data/lib/prosemirror/document.rb +6 -0
- data/lib/prosemirror/html/element.rb +1 -1
- data/lib/prosemirror/html/selector.rb +12 -4
- data/lib/prosemirror/html_parser.rb +9 -2
- data/lib/prosemirror/json_parser.rb +71 -40
- data/lib/prosemirror/mark.rb +8 -3
- data/lib/prosemirror/mark_type.rb +15 -0
- data/lib/prosemirror/markdown/builder.rb +19 -9
- data/lib/prosemirror/markdown_serializer.rb +516 -0
- data/lib/prosemirror/marks/basic/code.rb +2 -0
- data/lib/prosemirror/marks/basic/em.rb +2 -0
- data/lib/prosemirror/marks/basic/link.rb +2 -0
- data/lib/prosemirror/marks/basic/strong.rb +2 -0
- data/lib/prosemirror/marks/bold.rb +2 -0
- data/lib/prosemirror/marks/code.rb +2 -0
- data/lib/prosemirror/marks/italic.rb +2 -0
- data/lib/prosemirror/marks/link.rb +2 -0
- data/lib/prosemirror/marks/strike.rb +2 -0
- data/lib/prosemirror/node.rb +21 -6
- data/lib/prosemirror/node_type.rb +13 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
- data/lib/prosemirror/nodes/basic/image.rb +4 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
- data/lib/prosemirror/nodes/blockquote.rb +4 -0
- data/lib/prosemirror/nodes/bullet_list.rb +6 -0
- data/lib/prosemirror/nodes/code_block.rb +15 -0
- data/lib/prosemirror/nodes/hard_break.rb +5 -0
- data/lib/prosemirror/nodes/heading.rb +6 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
- data/lib/prosemirror/nodes/image.rb +16 -0
- data/lib/prosemirror/nodes/list_item.rb +4 -0
- data/lib/prosemirror/nodes/ordered_list.rb +11 -0
- data/lib/prosemirror/nodes/paragraph.rb +8 -0
- data/lib/prosemirror/nodes/table.rb +36 -0
- data/lib/prosemirror/nodes/text.rb +4 -0
- data/lib/prosemirror/schema.rb +14 -2
- data/lib/prosemirror/type_definition.rb +33 -13
- data/lib/prosemirror/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3717422ef93a6f35012d264a8d82b3fe9c2982631d5b2f39a499be9ad2da79a7
|
|
4
|
+
data.tar.gz: 05af2f5ff65a7a07e0f1134627fd6e4110d089264b9df1ad78f66000c4bda30e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 950422c7a48833915def7682efdb216a93c416acbfcb57ea712da29d220280a269d524013cc5bff57c0cc706fa243b7e0cd7080985bc8bd129224f182b1e4c62
|
|
7
|
+
data.tar.gz: 3914858a0eadcabd1d17dc253259a2c4786cb22332aff14b66c3d8fe89f9ea99de7c0ea4c6e70255fb23b9b992b543bd22bde54fdb2f55dbb6232185ba306168
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-10-24
|
|
4
|
+
|
|
5
|
+
- `Document#to_markdown` writes Markdown, with GitHub Flavored Markdown tables and strikethrough, like prosemirror-markdown. Node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`. `html: true` writes underline, highlight, subscript, superscript and image sizes as HTML.
|
|
6
|
+
- `Document#check` accepts attributes whose JSON name differs from their property name, like a link's `class`. It reported them as invalid.
|
|
7
|
+
- Faster: parsing JSON takes less than half the time, rendering and `to_h` about a third less, and HTML and Markdown import about a quarter less, with the same results. Strings in documents parsed from a JSON string are frozen, including the values of unknown attributes.
|
|
8
|
+
|
|
3
9
|
## [0.4.0] - 2026-10-24
|
|
4
10
|
|
|
5
11
|
- `Document#transform` builds a new document from a block called with each node, children first, which keeps, replaces, removes or splices nodes. Required content that goes missing is added back and reported.
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@ doc = Prosemirror.parse(record.content) # a Hash or a JSON string
|
|
|
24
24
|
|
|
25
25
|
doc.to_html # => "<h2>Hello</h2><p>World</p>"
|
|
26
26
|
doc.to_text # => "Hello\n\nWorld"
|
|
27
|
+
doc.to_markdown # => "## Hello\n\nWorld\n"
|
|
27
28
|
doc.to_h # => { "type" => "doc", "content" => [...] }
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -117,6 +118,23 @@ Raw HTML in Markdown goes through HTML import when the herb gem is installed, an
|
|
|
117
118
|
|
|
118
119
|
YAML front matter, between `---` lines at the start, is dropped. Footnotes aren't supported yet: `[^1]: Note` reads as a link definition, as in CommonMark. Markdown nested more than 1,000 levels deep raises `Prosemirror::Error`.
|
|
119
120
|
|
|
121
|
+
## Markdown export
|
|
122
|
+
|
|
123
|
+
`to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables and strikethrough, like [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown). It needs no gem.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
doc.to_markdown # => "## Hello\n\n**World**\n"
|
|
127
|
+
doc.to_markdown(html: true) # underline, highlight, subscript and superscript as HTML
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- Text is escaped, so that it reads back as text: `*`, `_`, `[`, a `#` or `-` that would start a block, HTML-like `<b>`, and URLs that GitHub would turn into links.
|
|
131
|
+
- Markdown has no syntax for underline, highlight, subscript, superscript or image sizes: by default, only their text or image is written. With `html: true`, they are written as HTML.
|
|
132
|
+
- Emphasis that CommonMark can't write where it is, like bold on a comma between two letters, is dropped and its text kept.
|
|
133
|
+
- Tables are written as GFM tables. A cell's paragraphs and line breaks are joined with `<br>`, and its other blocks, like lists, are written as HTML.
|
|
134
|
+
- Empty paragraphs are left out.
|
|
135
|
+
|
|
136
|
+
Importing the Markdown gives the document back, as far as Markdown can hold it. Custom types write their own Markdown: see [Node and mark types](TYPES.md#markdown-export).
|
|
137
|
+
|
|
120
138
|
## Text import
|
|
121
139
|
|
|
122
140
|
```ruby
|
data/ROADMAP.md
CHANGED
|
@@ -192,10 +192,12 @@ They differ in more than names:
|
|
|
192
192
|
```
|
|
193
193
|
- Validation against the schema (`check`).
|
|
194
194
|
|
|
195
|
-
### 0.5: Markdown export
|
|
195
|
+
### 0.5: Markdown export (released)
|
|
196
196
|
|
|
197
|
-
- `doc.to_markdown`,
|
|
198
|
-
-
|
|
197
|
+
- `doc.to_markdown`, ported from [`prosemirror-markdown`](https://github.com/ProseMirror/prosemirror-markdown)'s serializer. Useful to send content to LLMs.
|
|
198
|
+
- GitHub Flavored Markdown tables and strikethrough. Formatting without Markdown syntax, like underline, keeps its text, or is written as HTML with `html: true`.
|
|
199
|
+
- Custom node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`.
|
|
200
|
+
- Importing the Markdown gives the document back, as far as Markdown can hold it, tested on real-world Markdown and HTML.
|
|
199
201
|
|
|
200
202
|
### 0.6: More Tiptap node types
|
|
201
203
|
|
data/TYPES.md
CHANGED
|
@@ -27,6 +27,7 @@ A type is a [Phlex](https://www.phlex.fun) component, and its attributes are [Li
|
|
|
27
27
|
- [Rendering](#rendering)
|
|
28
28
|
- [Parse rules](#parse-rules)
|
|
29
29
|
- [Markdown](#markdown)
|
|
30
|
+
- [Markdown export](#markdown-export)
|
|
30
31
|
- [Subclassing built-in types](#subclassing-built-in-types)
|
|
31
32
|
|
|
32
33
|
## Names
|
|
@@ -203,6 +204,48 @@ Markdown import builds the elements commonmarker renders for each Markdown node,
|
|
|
203
204
|
|
|
204
205
|
Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
|
|
205
206
|
|
|
207
|
+
## Markdown export
|
|
208
|
+
|
|
209
|
+
A node type writes itself in `to_markdown`, with the methods of `Prosemirror::MarkdownSerializer`, ported from prosemirror-markdown's `MarkdownSerializerState`. As in `view_template`, attributes are instance variables, and `node` is the node:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class Callout < Prosemirror::NodeType
|
|
213
|
+
def to_markdown(markdown, parent, index)
|
|
214
|
+
markdown.wrap_block("> ", "> **Note:** ", node) { markdown.render_content(node) }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
| Method | Does |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `write(text)` | Writes text as it is, after closing the previous block. |
|
|
222
|
+
| `text(text, escape: true)` | Writes text, escaped. |
|
|
223
|
+
| `render_inline(node)` | Writes the node's inline content, with its marks. |
|
|
224
|
+
| `render_content(node)` | Writes the node's blocks. |
|
|
225
|
+
| `wrap_block(delim, first_delim, node) { … }` | Writes a block with each line prefixed, like `> `. |
|
|
226
|
+
| `render_list(node, delim) { \|index\| marker }` | Writes a list. |
|
|
227
|
+
| `close_block(node)` | Ends the block: the next one starts after a blank line. |
|
|
228
|
+
| `esc(text)`, `url(href)`, `node_html(node)`, `html?` | Escaping, a link destination, a node's HTML, and whether `html: true` was given. |
|
|
229
|
+
|
|
230
|
+
Without `to_markdown`, a type writes its content.
|
|
231
|
+
|
|
232
|
+
A mark type declares its syntax with `markdown`, as prosemirror-markdown's mark specs:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
class Keyboard < Prosemirror::MarkType
|
|
236
|
+
markdown open: "<kbd>", close: "</kbd>", escape: false
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
| Option | Meaning |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `open`, `close` | Strings, or blocks called with the serializer, the mark, the parent node and the index of the marked node. |
|
|
243
|
+
| `mixable: true` | The mark can open and close in any order with other mixable marks, like `*` and `**`. |
|
|
244
|
+
| `expel_enclosing_whitespace: true` | Spaces at the edges move outside the mark, as emphasis requires. |
|
|
245
|
+
| `escape: false` | The content isn't escaped, as in inline code. The mark must come last. |
|
|
246
|
+
|
|
247
|
+
Without `markdown`, a mark writes its text, or its HTML with `to_markdown(html: true)`.
|
|
248
|
+
|
|
206
249
|
## Subclassing built-in types
|
|
207
250
|
|
|
208
251
|
A subclass keeps the name, options, attributes and parse rules of its parent, and can change any of them. Register it with `Schema#with`, which replaces the built-in type with the same name:
|
data/lib/prosemirror/checker.rb
CHANGED
|
@@ -63,10 +63,10 @@ module Prosemirror
|
|
|
63
63
|
invalid("invalid marks: #{node.marks.map { |mark| @schema.json_type_name(mark.type) }.join(" ")}", path) unless sorted == node.marks
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# Attributes are valid when building them from
|
|
66
|
+
# Attributes are valid when building them from their JSON reports nothing and changes nothing.
|
|
67
67
|
def check_attrs(type, attrs, path)
|
|
68
68
|
problems = []
|
|
69
|
-
built = type.build_attrs(attrs, report: ->(message) { problems << message })
|
|
69
|
+
built = type.build_attrs(type.serialize_attrs(attrs), report: ->(message) { problems << message })
|
|
70
70
|
invalid(problems.first, path) unless problems.empty?
|
|
71
71
|
invalid("invalid attributes: #{attrs.inspect}", path) unless built == attrs
|
|
72
72
|
end
|
data/lib/prosemirror/coerce.rb
CHANGED
|
@@ -13,5 +13,12 @@ module Prosemirror
|
|
|
13
13
|
|
|
14
14
|
# A frozen copy, unless the value is already frozen.
|
|
15
15
|
FROZEN = proc { |value| value.frozen? ? value : value.dup.freeze }
|
|
16
|
+
|
|
17
|
+
# The coercions above, which don't use `self`.
|
|
18
|
+
PLAIN = [INTEGER, SYMBOL, FROZEN].freeze
|
|
19
|
+
|
|
20
|
+
def self.frozen(value)
|
|
21
|
+
value.frozen? ? value : value.dup.freeze
|
|
22
|
+
end
|
|
16
23
|
end
|
|
17
24
|
end
|
data/lib/prosemirror/document.rb
CHANGED
|
@@ -47,6 +47,12 @@ module Prosemirror
|
|
|
47
47
|
TextSerializer.new(schema, block_separator: block_separator).call(root)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# The document as Markdown (GitHub Flavored Markdown for tables and strikethrough). Formatting Markdown
|
|
51
|
+
# has no syntax for, like underline, keeps only its text, or is written as HTML with `html: true`.
|
|
52
|
+
def to_markdown(html: false)
|
|
53
|
+
MarkdownSerializer.new(schema, html: html).call(root)
|
|
54
|
+
end
|
|
55
|
+
|
|
50
56
|
# The document as JSON, as a Hash.
|
|
51
57
|
def to_h
|
|
52
58
|
schema.serialize(root)
|
|
@@ -15,22 +15,30 @@ module Prosemirror
|
|
|
15
15
|
|
|
16
16
|
def initialize(source)
|
|
17
17
|
@source = source
|
|
18
|
-
@
|
|
18
|
+
@names = []
|
|
19
|
+
@compounds = source.split(",").map { |part| parse_compound(part.strip, top: true) }
|
|
20
|
+
@names = @names.include?(nil) ? nil : @names.uniq.freeze
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def match?(element)
|
|
22
24
|
@compounds.any? { |tests| tests.all? { |test| test.call(element) } }
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# The element names it can match, or nil when it can match any element.
|
|
28
|
+
attr_reader :names
|
|
29
|
+
|
|
25
30
|
private
|
|
26
31
|
|
|
27
|
-
def parse_compound(part)
|
|
32
|
+
def parse_compound(part, top: false)
|
|
28
33
|
invalid! if part.empty?
|
|
29
34
|
|
|
30
35
|
tests = []
|
|
31
36
|
rest = part
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
type = rest[/\A(?:\*|#{IDENTIFIER})/o]
|
|
38
|
+
name = type.downcase.freeze if type && type != "*"
|
|
39
|
+
@names << name if top
|
|
40
|
+
if type
|
|
41
|
+
tests << ->(element) { element.name == name } if name
|
|
34
42
|
rest = rest[type.length..]
|
|
35
43
|
end
|
|
36
44
|
until rest.empty?
|
|
@@ -34,6 +34,11 @@ module Prosemirror
|
|
|
34
34
|
rules = (schema.marks + schema.nodes).flat_map(&:parse_rules)
|
|
35
35
|
rules = rules.each_with_index.sort_by { |rule, index| [-rule.priority, index] }.map(&:first)
|
|
36
36
|
@tags = rules.select(&:tag)
|
|
37
|
+
@selectors = @tags.map { |rule| HTML::Selector.parse(rule.tag) }
|
|
38
|
+
# The indexes of the tag rules that can match an element with this name, in order.
|
|
39
|
+
@candidates = Hash.new do |candidates, name|
|
|
40
|
+
candidates[name] = @selectors.each_index.select { |index| @selectors[index].names.nil? || @selectors[index].names.include?(name) }.freeze
|
|
41
|
+
end
|
|
37
42
|
@styles = rules.select(&:style)
|
|
38
43
|
@matched_styles = @styles.map(&:style_property).uniq
|
|
39
44
|
@normalize_lists = @tags.none? { |rule| lists_contain_themselves?(rule) }
|
|
@@ -53,9 +58,11 @@ module Prosemirror
|
|
|
53
58
|
|
|
54
59
|
# The first tag rule after `after` that matches the element in this context.
|
|
55
60
|
def match_tag(element, context, after = nil)
|
|
56
|
-
|
|
61
|
+
@candidates[element.name].each do |index|
|
|
62
|
+
next if after && index <= after
|
|
63
|
+
|
|
57
64
|
rule = @tags[index]
|
|
58
|
-
next unless
|
|
65
|
+
next unless @selectors[index].match?(element)
|
|
59
66
|
next if rule.context && !context.matches_context?(rule.context)
|
|
60
67
|
|
|
61
68
|
match = build_match(rule, index, element) { |get_attrs| call_get_attrs(get_attrs, element) }
|
|
@@ -6,19 +6,26 @@ module Prosemirror
|
|
|
6
6
|
# Invalid parts are fixed and reported to the schema (which raises in strict mode).
|
|
7
7
|
class JSONParser
|
|
8
8
|
MAX_DEPTH = 100
|
|
9
|
+
EMPTY = [].freeze
|
|
9
10
|
|
|
10
11
|
def initialize(schema)
|
|
11
12
|
@schema = schema
|
|
13
|
+
# The path of the node being built, as `["doc", :content, 0, :marks, 1]`: a string is only made for
|
|
14
|
+
# a report.
|
|
15
|
+
@path = []
|
|
16
|
+
@report = ->(message) { invalid(message) }
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
def parse(json)
|
|
15
|
-
|
|
20
|
+
# Frozen strings go into nodes without being copied.
|
|
21
|
+
hash = json.is_a?(String) ? JSON.parse(json, max_nesting: (MAX_DEPTH * 2) + 2, freeze: true) : json
|
|
16
22
|
raise ArgumentError, "expected a Hash or a JSON String, got #{json.class}" unless hash.is_a?(Hash)
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
@path = ["doc"]
|
|
25
|
+
root = build_node(hash, 0) || Node.new(type: @schema.top_node)
|
|
19
26
|
return root if root.type == @schema.top_node
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
invalid("the top node is #{root.type}, not #{@schema.top_node}")
|
|
22
29
|
Node.new(type: @schema.top_node, content: [root])
|
|
23
30
|
rescue JSON::NestingError
|
|
24
31
|
raise InvalidDocument, "the document is nested more than #{MAX_DEPTH} levels deep"
|
|
@@ -26,88 +33,112 @@ module Prosemirror
|
|
|
26
33
|
|
|
27
34
|
private
|
|
28
35
|
|
|
29
|
-
def build_node(hash,
|
|
30
|
-
return invalid("not a node: #{hash.inspect}"
|
|
31
|
-
return invalid("nested more than #{MAX_DEPTH} levels deep"
|
|
36
|
+
def build_node(hash, depth)
|
|
37
|
+
return invalid("not a node: #{hash.inspect}") unless hash.is_a?(Hash)
|
|
38
|
+
return invalid("nested more than #{MAX_DEPTH} levels deep") if depth > MAX_DEPTH
|
|
32
39
|
|
|
33
40
|
name = fetch(hash, "type")
|
|
34
|
-
return invalid("missing node type"
|
|
35
|
-
return build_text(hash
|
|
41
|
+
return invalid("missing node type") unless name.is_a?(String) || name.is_a?(Symbol)
|
|
42
|
+
return build_text(hash) if name == "text" || name == :text
|
|
36
43
|
|
|
37
44
|
node_type = @schema.node_type(name)
|
|
38
|
-
return build_unknown(hash, name,
|
|
45
|
+
return build_unknown(hash, name, depth) unless node_type
|
|
39
46
|
|
|
40
47
|
given = fetch(hash, "attrs")
|
|
41
|
-
attrs = node_type.build_attrs(given, report:
|
|
42
|
-
return invalid("removed #{name} node"
|
|
48
|
+
attrs = node_type.build_attrs(given, report: @report)
|
|
49
|
+
return invalid("removed #{name} node") unless attrs
|
|
43
50
|
|
|
44
|
-
Node.new(type: node_type.type_name, attrs: attrs, unknown_attrs: node_type.unknown_attrs(given),
|
|
45
|
-
content: build_content(hash,
|
|
51
|
+
Node.new(type: node_type.type_name, attrs: attrs.freeze, unknown_attrs: node_type.unknown_attrs(given).freeze,
|
|
52
|
+
content: build_content(hash, depth), marks: build_marks(fetch(hash, "marks")))
|
|
46
53
|
end
|
|
47
54
|
|
|
48
|
-
def build_text(hash
|
|
55
|
+
def build_text(hash)
|
|
49
56
|
text = fetch(hash, "text")
|
|
50
|
-
return invalid("text node without text"
|
|
57
|
+
return invalid("text node without text") unless text.is_a?(String)
|
|
51
58
|
return nil if text.empty?
|
|
52
59
|
|
|
53
|
-
Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks")
|
|
60
|
+
Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks")))
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
def build_unknown(hash, name,
|
|
57
|
-
invalid("unknown node type #{name}"
|
|
63
|
+
def build_unknown(hash, name, depth)
|
|
64
|
+
invalid("unknown node type #{name}")
|
|
58
65
|
attrs = fetch(hash, "attrs")
|
|
59
66
|
Node.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {},
|
|
60
|
-
content: build_content(hash,
|
|
67
|
+
content: build_content(hash, depth), marks: build_marks(fetch(hash, "marks")))
|
|
61
68
|
end
|
|
62
69
|
|
|
63
|
-
def build_content(hash,
|
|
70
|
+
def build_content(hash, depth)
|
|
64
71
|
content = fetch(hash, "content")
|
|
65
|
-
return
|
|
66
|
-
return invalid("content is not a list"
|
|
72
|
+
return EMPTY if content.nil?
|
|
73
|
+
return invalid("content is not a list") || EMPTY unless content.is_a?(Array)
|
|
74
|
+
|
|
75
|
+
nodes = []
|
|
76
|
+
content.each_with_index do |child, index|
|
|
77
|
+
@path.push(:content, index)
|
|
78
|
+
node = build_node(child, depth + 1)
|
|
79
|
+
@path.pop
|
|
80
|
+
@path.pop
|
|
81
|
+
nodes << node if node
|
|
82
|
+
end
|
|
83
|
+
Node.join_text(nodes).freeze
|
|
84
|
+
end
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
def build_marks(marks)
|
|
87
|
+
return EMPTY if marks.nil?
|
|
88
|
+
return invalid("marks is not a list") || EMPTY unless marks.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
built = []
|
|
91
|
+
marks.each_with_index do |mark, index|
|
|
92
|
+
@path.push(:marks, index)
|
|
93
|
+
mark = build_mark(mark)
|
|
94
|
+
@path.pop
|
|
95
|
+
@path.pop
|
|
96
|
+
built << mark if mark
|
|
70
97
|
end
|
|
71
|
-
|
|
98
|
+
sort_marks(built).freeze
|
|
72
99
|
end
|
|
73
100
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return
|
|
101
|
+
# The marks without duplicates, in schema order. Most sets have one mark, or are already sorted.
|
|
102
|
+
def sort_marks(marks)
|
|
103
|
+
return marks if marks.size < 2
|
|
104
|
+
|
|
105
|
+
marks = marks.uniq
|
|
106
|
+
ranks = marks.map { |mark| @schema.mark_rank(mark.type) }
|
|
107
|
+
return marks if ranks.each_cons(2).all? { |a, b| a <= b }
|
|
77
108
|
|
|
78
|
-
|
|
79
|
-
built.uniq.sort_by.with_index { |mark, index| [@schema.mark_rank(mark.type), index] }
|
|
109
|
+
marks.each_with_index.sort_by { |mark, index| [ranks[index], index] }.map(&:first)
|
|
80
110
|
end
|
|
81
111
|
|
|
82
|
-
def build_mark(hash
|
|
83
|
-
return invalid("not a mark: #{hash.inspect}"
|
|
112
|
+
def build_mark(hash)
|
|
113
|
+
return invalid("not a mark: #{hash.inspect}") unless hash.is_a?(Hash)
|
|
84
114
|
|
|
85
115
|
name = fetch(hash, "type")
|
|
86
|
-
return invalid("missing mark type"
|
|
116
|
+
return invalid("missing mark type") unless name.is_a?(String) || name.is_a?(Symbol)
|
|
87
117
|
|
|
88
118
|
mark_type = @schema.mark_type(name)
|
|
89
119
|
unless mark_type
|
|
90
|
-
invalid("unknown mark type #{name}"
|
|
120
|
+
invalid("unknown mark type #{name}")
|
|
91
121
|
attrs = fetch(hash, "attrs")
|
|
92
122
|
return Mark.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {})
|
|
93
123
|
end
|
|
94
124
|
|
|
95
125
|
given = fetch(hash, "attrs")
|
|
96
|
-
attrs = mark_type.build_attrs(given, report:
|
|
97
|
-
return invalid("removed #{name} mark"
|
|
126
|
+
attrs = mark_type.build_attrs(given, report: @report)
|
|
127
|
+
return invalid("removed #{name} mark") unless attrs
|
|
98
128
|
|
|
99
|
-
Mark.new(type: mark_type.type_name, attrs: attrs, unknown_attrs: mark_type.unknown_attrs(given))
|
|
129
|
+
Mark.new(type: mark_type.type_name, attrs: attrs.freeze, unknown_attrs: mark_type.unknown_attrs(given).freeze)
|
|
100
130
|
end
|
|
101
131
|
|
|
102
132
|
def fetch(hash, key)
|
|
103
133
|
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
104
134
|
end
|
|
105
135
|
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
# The path as written in reports: `doc.content[0].marks[1]`.
|
|
137
|
+
def path
|
|
138
|
+
([@path.first] + @path.drop(1).each_slice(2).map { |name, index| "#{name}[#{index}]" }).join(".")
|
|
108
139
|
end
|
|
109
140
|
|
|
110
|
-
def invalid(message
|
|
141
|
+
def invalid(message)
|
|
111
142
|
@schema.report(message, path: path)
|
|
112
143
|
end
|
|
113
144
|
end
|
data/lib/prosemirror/mark.rb
CHANGED
|
@@ -7,9 +7,14 @@ module Prosemirror
|
|
|
7
7
|
#
|
|
8
8
|
# As for nodes, `unknown_attrs` holds the attributes the mark's type doesn't declare.
|
|
9
9
|
class Mark < Literal::Data
|
|
10
|
-
prop :type, Symbol
|
|
11
|
-
prop :attrs, _Hash(Symbol, _Any?), default:
|
|
12
|
-
prop :unknown_attrs, _Hash(String, _Any?), default:
|
|
10
|
+
prop :type, Symbol
|
|
11
|
+
prop :attrs, _Hash(Symbol, _Any?), default: Node::EMPTY_HASH
|
|
12
|
+
prop :unknown_attrs, _Hash(String, _Any?), default: Node::EMPTY_HASH
|
|
13
|
+
|
|
14
|
+
# As Node#initialize.
|
|
15
|
+
def initialize(type:, attrs: Node::EMPTY_HASH, unknown_attrs: Node::EMPTY_HASH)
|
|
16
|
+
super(type: type.is_a?(String) ? type.to_sym : type, attrs: Coerce.frozen(attrs), unknown_attrs: Coerce.frozen(unknown_attrs))
|
|
17
|
+
end
|
|
13
18
|
|
|
14
19
|
# A copy with some properties changed.
|
|
15
20
|
def with(**changes)
|
|
@@ -44,6 +44,21 @@ module Prosemirror
|
|
|
44
44
|
inherited_setting(:@group, [])
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# The mark's Markdown syntax, as prosemirror-markdown's MarkSerializerSpec:
|
|
48
|
+
# - `open` and `close`: strings, or blocks called with the MarkdownSerializer, the mark, the parent node
|
|
49
|
+
# and the index of the marked node;
|
|
50
|
+
# - `mixable`: the mark can open and close in any order with other mixable marks, like `*` and `**`;
|
|
51
|
+
# - `expel_enclosing_whitespace`: spaces at the edges move outside the mark, as emphasis requires;
|
|
52
|
+
# - `escape: false`: the content isn't escaped, as in inline code. The mark must come last.
|
|
53
|
+
# Without it, the mark keeps only its text, or its HTML with `to_markdown(html: true)`.
|
|
54
|
+
def markdown(open:, close:, mixable: false, expel_enclosing_whitespace: false, escape: true)
|
|
55
|
+
@markdown_spec = {open:, close:, mixable:, expel_enclosing_whitespace:, escape:}.freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def markdown_spec
|
|
59
|
+
inherited_setting(:@markdown_spec, nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
47
62
|
# Whether one element can wrap several adjacent nodes with this mark. Defaults to true.
|
|
48
63
|
def spanning(value = nil)
|
|
49
64
|
return @spanning = value unless value.nil?
|
|
@@ -50,6 +50,14 @@ module Prosemirror
|
|
|
50
50
|
root
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# The URL as commonmarker renders it, with the characters URLs can't hold percent-encoded. Markdown export
|
|
54
|
+
# writes URLs this way too, so that importing its output changes nothing.
|
|
55
|
+
def self.escape_url(url)
|
|
56
|
+
host = url[IPV6_HOST].to_s
|
|
57
|
+
rest = url.delete_prefix(host).b.gsub(URL_UNSAFE) { |byte| byte.ord.zero? ? "%EF%BF%BD" : format("%%%02X", byte.ord) }
|
|
58
|
+
host + rest.force_encoding(Encoding::UTF_8)
|
|
59
|
+
end
|
|
60
|
+
|
|
53
61
|
private
|
|
54
62
|
|
|
55
63
|
def parse(markdown)
|
|
@@ -78,8 +86,12 @@ module Prosemirror
|
|
|
78
86
|
|
|
79
87
|
# Adds a node to the target. Returns the element to add its children to, when they need adding.
|
|
80
88
|
def add(node, target)
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
type = node.type # commonmarker allocates for each call
|
|
90
|
+
if (tag = TAGS[type])
|
|
91
|
+
return HTML::Element.new(tag)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
case type
|
|
83
95
|
when :heading then HTML::Element.new("h#{node.header_level}")
|
|
84
96
|
when :list then list(node)
|
|
85
97
|
when :code_block then code_block(node, target)
|
|
@@ -157,7 +169,10 @@ module Prosemirror
|
|
|
157
169
|
end
|
|
158
170
|
|
|
159
171
|
def raw_html?(document)
|
|
160
|
-
document.walk
|
|
172
|
+
document.walk do |node|
|
|
173
|
+
type = node.type
|
|
174
|
+
return true if type == :html_block || type == :html_inline
|
|
175
|
+
end
|
|
161
176
|
false
|
|
162
177
|
end
|
|
163
178
|
|
|
@@ -181,12 +196,7 @@ module Prosemirror
|
|
|
181
196
|
end
|
|
182
197
|
end
|
|
183
198
|
|
|
184
|
-
|
|
185
|
-
def escape_url(url)
|
|
186
|
-
host = url[IPV6_HOST].to_s
|
|
187
|
-
rest = url.delete_prefix(host).b.gsub(URL_UNSAFE) { |byte| byte.ord.zero? ? "%EF%BF%BD" : format("%%%02X", byte.ord) }
|
|
188
|
-
host + rest.force_encoding(Encoding::UTF_8)
|
|
189
|
-
end
|
|
199
|
+
def escape_url(url) = self.class.escape_url(url)
|
|
190
200
|
|
|
191
201
|
def plain_text(node)
|
|
192
202
|
node.to_a.map { |child| %i[text code].include?(child.type) ? child.string_content : plain_text(child) }.join
|